iT邦幫忙

2022 iThome 鐵人賽

DAY 10
1
Modern Web

派對動物嗨起來!系列 第 10

D10 - 讓前後端接上線:使用 Socket.IO 進行連線

  • 分享至 

  • xImage
  •  

本系列文已改編成書「甚麼?網頁也可以做派對遊戲?使用 Vue 和 babylon.js 打造 3D 派對遊戲吧!」

書中不只重構了程式架構、改善了介面設計,還新增了 2 個新遊戲呦!ˋ( ° ▽、° )

新遊戲分別使用了陀螺儀與震動回饋,趕快買書來研究研究吧!ლ(╹∀╹ლ)

在此感謝深智數位的協助,歡迎大家前往購書,鱈魚感謝大家 (。・∀・)。

助教:「所以到底差在哪啊?沒圖沒真相,被你坑了都不知道。(´。_。`)」

鱈魚:「你對我是不是有甚麼很深的偏見啊 (っ °Д °;)っ,來人啊,上連結!」

Yes


目前我們已經成功新增 ws-client 模組,ws-client 模組的主要功能是「管理 socketId 與 clientId 的映射關係」。

目的是「只要 client 傳輸的 id 相同,即使 socket 改變,都可以取得先前的連線資料。」

基本概念為:

  1. client 發起連線並傳送 id。
  2. server 取得 id 後,檢查是否有此 id 連線資料。
  3. 已存在則替換新的 socket id;不存在則建立新資料。

接著來介紹一下,目前 ws-client 資料夾內的檔案分別功能為何:

  • ws-client.gateway.ts

    負責處理請求,概念與 MVC 模型中的 controller 功能類似。

  • ws-client.gateway.spec.ts:

    gateway 測試檔案,負責測試 ws-client.gateway 功能,本次專案不會用到。

  • ws-client.service.ts

    負責提供 ws-client 功能邏輯。

  • ws-client.service.spec.ts

    service 測試檔案,負責測試 ws-client.service 功能,本次專案不會用到。

  • ws-client.module.ts

    負責包裝以上檔案成為一個獨立模組。

首先讓我們完成 ws-client.service 功能邏輯,設計一下預期使用的型別定義。

src\ws-client\ws-client.service.ts

import { Injectable } from '@nestjs/common';

export enum ClientType {
  /** 遊戲機,負責建立派對房間 */
  GAME_CONSOLE = 'game-console',
  /** 玩家,通常是手機端網頁 */
  PLAYER = 'player',
}

export type ClientId = string;

export interface Client {
  id: ClientId;
  socketId: string;
  type: `${ClientType}`;
}

@Injectable()
export class WsClientService {}

新增 Map 物件儲存目前已連線之 client。

import { Injectable } from '@nestjs/common';

...

@Injectable()
export class WsClientService {
  clientsMap = new Map<ClientId, Client>();
}

接著新增新增或替換 Client 的 method。

import { Injectable } from '@nestjs/common';

...

export interface PutClientParams {
  socketId: string;
  clientId: ClientId;
  type: `${ClientType}`;
}

@Injectable()
export class WsClientService {
  clientsMap = new Map<ClientId, Client>();

  /** 不存在則新增,存在則更新 */
  putClient(params: PutClientDto) {
    const { clientId, socketId, type } = params;

    const client = this.clientsMap.get(clientId);

    // 新增
    if (!client) {
      const newClient = { id: clientId, socketId, type };
      this.clientsMap.set(clientId, newClient);
      return newClient;
    }

    // 更新
    client.socketId = socketId;
    client.type = type;

    this.clientsMap.set(clientId, client);

    return client;
  }
}

儲存 client 之後,當然還要可以取出才行,最後我們新增取得 client 用的 method。

...

/** 允許使用 socketId 或 clientId 取得 */
export type GetClientParams = { socketId: string } | { clientId: string };

@Injectable()
export class WsClientService {
  ...
  getClient(params: GetClientParams) {
    // 存在 clientId,使用 clientId 取得
    if ('clientId' in params) {
      return this.clientsMap.get(params.clientId);
    }

    // 否則用 socketId 查詢
    const clients = [...this.clientsMap.values()];
    const target = clients.find(({ socketId }) => socketId === params.socketId);
    return target;
  }
}

service 基本上這樣就完成了,接下來完成 gateway 的功能吧。( ´ ▽ ` )ノ

ws-client.gateway 的工作其實非常單純,只專注於 client 連線事件。並於連線時,取得 clientId 並呼叫 service。

根據 NestJS 官網的介紹,只要新增名為 handleConnection 的 method,只要 client 連線時,NestJS 就會自動呼叫此名稱的 method。

src\ws-client\ws-client.gateway.ts

...
import { Socket } from 'socket.io';

@WebSocketGateway()
export class WsClientGateway {
  constructor(private readonly wsClientService: WsClientService) {
    //
  }

  handleConnection(socket: Socket) {
  }
}

method 好了,所以我們要怎麼取得 client 連線時傳輸的訊息?

根據 Socket.IO 文件的說法,伺服器可以透過 socket.handshake.query 的方式取得資料。

...
export class WsClientGateway {
  ...
  handleConnection(socket: Socket) {
    const queryData = socket.handshake.query;
  }
}

像這樣就可以取得 client 連線時傳輸的資料,只是現在有另一個問題:「我們要怎麼確定 queryData 的資料定義正確?」

在此需要一個可以判斷物件型別是否正確的 function,所以讓我們新增新的模組,用來處理各類資料吧。

輸入以下指令,建立 utils 模組。

nest g resource utils
  • What transport layer do you use?

    選哪一個都可以,因為用不到 controller

  • Would you like to generate CRUD entry points?

    輸入「n」

之後就可以看到目錄下新增了 utils 模組。

Untitled

接著讓我們刪除 controller 的檔案(因為這個模組不處理請求),並調整 utils.module 內容。

src\utils\utils.module.ts

import { Module } from '@nestjs/common';
import { UtilsService } from './utils.service';

@Global()
@Module({
  providers: [UtilsService],
  exports: [UtilsService],
})
export class UtilsModule {
  //
}
  • @Global() 裝飾器會讓此模組變成全域模組,也就是在任意地方都可以存取。
  • exports 則表示此模組要對外提供那些內容。

接著讓我們完成 utils.service 功能,我們希望 queryData 要包含 clientId 與 type 資料,新增名為 isSocketQueryData 的 method 進行判斷。

src\utils\utils.service.ts

import { Injectable } from '@nestjs/common';
import { ClientType } from 'src/ws-client/ws-client.service';

interface SocketQueryData {
  clientId: string;
  type: `${ClientType}`;
}

@Injectable()
export class UtilsService {
  isSocketQueryData(data: any): data is SocketQueryData {
    // 沒有必要屬性
    if (!('clientId' in data) || !('type' in data)) {
      return false;
    }

    // type 不屬於列舉類型
    if (!Object.values(ClientType).includes(data['type'])) {
      return false;
    }

    return true;
  }
}
}

接下來讓我們回到 ws-client.gateway,來使用 utils 模組功能吧!

src\ws-client\ws-client.gateway.ts

...
import { UtilsService } from 'src/utils/utils.service';
...
export class WsClientGateway {
  constructor(
    private readonly wsClientService: WsClientService,
    private readonly utilsService: UtilsService,
  ) {
    //
  }

  handleConnection(socket: Socket) {
    const queryData = socket.handshake.query as unknown;

    // 若資料無效,則中斷連線
    if (!this.utilsService.isSocketQueryData(queryData)) {
      socket.disconnect();
      return;
    }

    const { clientId, type } = queryData;
  }
}

現在我們可以確定 queryData 內容一定是我們希望的資料了,接續完成此 method 吧。

...
@WebSocketGateway()
export class WsClientGateway {
  ...

  handleConnection(socket: Socket) {
    ...

    const { clientId, type } = queryData;

    this.wsClientService.putClient({
      socketId: socket.id,
      clientId,
      type,
    });
  }
}

其實只要呼叫 this.wsClientService.putClient 就完成了。(´,,•ω•,,)

不過還是加個 logger,方便查看訊息吧。

...
import { Logger } from '@nestjs/common';
...
export class WsClientGateway {
  private logger: Logger = new Logger(WsClientGateway.name);
  ...
  handleConnection(socket: Socket) {
    this.logger.log(`client connected : ${socket.id}`);

    const queryData = socket.handshake.query as unknown;
    this.logger.log(`queryData : `, queryData);
    ...
  }
}

(以此專案規模而言,使用 console.log 就足夠了,只是在持續增長的專案中,用 logger 可以保留未來拓展彈性,所以養成好習慣,這裡就使用 logger 吧。( ´ ▽ ` )ノ)

最終在 ws-client.module 加上 exports,以便未來有需求時,別的模組也能使用 service

src\ws-client\ws-client.module.ts

import { Module } from '@nestjs/common';
import { WsClientService } from './ws-client.service';
import { WsClientGateway } from './ws-client.gateway';

@Module({
  providers: [WsClientGateway, WsClientService],
  exports: [WsClientService],
})
export class WsClientModule {
  //
}

以上我們已經準備好可以連線的伺服器了,現在讓我們回到 Web 專案。

首先新增 main.type 定義基本資料。

src\types\main.type.ts

export enum ClientType {
  /** 遊戲主機 */
  GAME_CONSOLE = 'game-console',
  /** 玩家 */
  PLAYER = 'player',
}

再新增 main.store 用來儲存 socket.io 之 client。

src\stores\main.store.ts

import { defineStore } from 'pinia';
import { Socket } from 'socket.io-client';
import { ClientType } from '../types/main.type';
import { nanoid } from 'nanoid';

interface State {
  /** 儲存於 LocalStorage 中,識別是否為同一個連線 */
  clientId: string,

  /** Socket.io Client 物件 */
  client?: Socket,
  type?: `${ClientType}`,
}

export const useMainStore = defineStore('main', {
  state: (): State => {
    const clientId = localStorage.getItem(`animals-party:clientId`) ?? nanoid();

    return {
      clientId,
      client: undefined,
      type: undefined,
    }
  },

  actions: {
    setClientId(id: string) {
      this.$patch({
        clientId: id
      });

      localStorage.setItem(`animals-party:clientId`, id);
    },

    setClient(client: Socket, type: `${ClientType}`) {
      this.$patch({
        client,
        type
      });
    }
  }
})

可以注意到在 setClientId 中,我們也同時將 clientId 儲存至 localStorage,並在每次初始化時取得 localStorage 已儲存 id,若沒有 id,則使用 nanoid 建立。

接著新增 use-socket-client,用來負責封裝各類 socket 功能吧。

src\composables\use-socket-client.ts

import { ref } from 'vue'
import { io, Socket } from "socket.io-client";
import { nanoid } from 'nanoid';
import { ClientType } from '../types/main.type';

import { useMainStore } from '../stores/main.store';
import { storeToRefs } from 'pinia';

export function useSocketClient() {
  const mainStore = useMainStore();
  const { client } = storeToRefs(mainStore);

  function connect(type: `${ClientType}`) {
    if (!mainStore.clientId) {
      mainStore.setClientId(nanoid());
    }

    // 已經存在
    if (mainStore.client) {
      mainStore.client.connect();
      return mainStore.client;
    }

    // 建立連線,傳送 query data
    const client: Socket = io({
      query: {
        clientId: mainStore.clientId,
        type
      }
    });

    mainStore.setClient(client, type);
    return client;
  }

  function close() {
    mainStore.client?.close();
  }

  return {
    client,

    connect,
    close
  }
}

最後回到 the-home,讓我們實際在 startParty() 中,試試看啟動連線吧!(≧∀≦)

src\views\the-home.vue

<script setup lang="ts">
...
const { connect } = useSocketClient();

async function startParty() {
  await loading.show();

  connect('game-console');

  router.push({
    name: RouteName.GAME_CONSOLE
  });
}
</script>

這裡還有一件非常非常重要的事情,就是要設定 Vite 的 proxy 功能,請 Vite 的 Dev Server 協助代理傳輸資料,否則會怎麼樣都無法連線喔!

讓我們前往 vite.config 設定

vite.config.ts

...

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    ...
  ],

  server: {
    proxy: {
      '/socket.io': {
        target: 'ws://localhost/socket.io',
        ws: true
      }
    }
  }
})

現在按下首頁的「建立派對」後,應該會在伺服器的終端機中到以下訊息。

Untitled

我們成功讓前後端連線了!✧*。٩(ˊᗜˋ*)و✧*。

總結

  • 完成伺服器儲存 client 功能
  • 完成網頁建立、儲存 client 並連接至伺服器功能

以上程式碼已同步至 GitLab,大家可以前往下載:

Web

GitLab - D10

Server

GitLab - D10


上一篇
D09 - NestJS 是啥?好吃嗎?
下一篇
D11 - 開房間!開派對!♪( ◜ω◝و(و
系列文
派對動物嗨起來!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言